![[為你自己寫 Vue Component] AtomicRating](https://ithelp.ithome.com.tw/upload/images/20241005/20120484eqRV6x7nei.png)
Rating 元件讓使用者可以對某項目進行評分,通常以星星或其他符號來表示評分等級。Rating 元件的核心功能是提供一種直觀的方式,讓使用者針對產品、服務或內容表達他們的滿意度或偏好。它可以是靜態顯示使用者的評分,也可以是可互動的,讓使用者自行選擇評分。

在開始實作前,我們先研究各個 UI Library 的 Rating 元件是如何設計的。
Element Plus

<template>
  <ElRate
    v-model="value1"
    :max="5"
    size="large"
  />
</template>
Element Plus 的 <ElRate> 元件可以使用 v-model 來雙向綁定評分值,並且可以設定 max 來設定最大值,size 來設定元件大小。
除此之外,Element Plus 也實現了鍵盤操作功能,使用者可以使用鍵盤上的左右鍵來增減評分值。

Vuetify

<template>
  <VRating
    v-model="value"
    active-color="primary"
    hover
    :length="5"
    :size="34"
  />
</template>
Vuetify 的 <VRating> 元件也可以使用 v-model 來雙向綁定評分值,並且可以設定 active-color 來設定選取的顏色,length 來設定最大值,size 來設定元件大小。
另外,<VRating> 預設並不具備滑鼠懸停在某個評分上的 feedback 的效果,如果需要可以使用 hover 這個 prop 來啟用。
PrimeVue

<template>
  <Rating v-model="value" :stars="5" />
</template>
PrimeVue 的 <Rating> 元件也可以使用 v-model 來雙向綁定評分值,並且可以設定 stars 來設定最大值。
PrimeVue 在鍵盤操作上也有很好的支援,使用者可以使用鍵盤上的左右鍵來增減評分的值。

綜合以上並結合自身經驗,我們統整出 <AtomicRating> 的功能:
使用結構如下:
v-model 來雙向綁定評分的值。max 來設定 Rating 的最大值。size 來設定 Rating 元件的大小。disabled 來禁用評分。readonly 來設定唯讀模式。<template>
  <AtomicRating
    v-model="rating"
    max="5"
    size="medium"
  />
</template>
首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| modelValue | number | 評分的值 | |
| max | number,string | 5 | Rating 的最大值 | 
| name | string | 評分欄位的名稱 | |
| size | 'small','medium','large' | 'medium' | Rating 元件的大小 | 
| disabled | boolean | false | 是否禁用評分 | 
| readonly | boolean | false | 是否為唯讀模式 | 
type Numberish = number | `${number}`;
interface AtomicRatingProps {
  modelValue: number;
  max?: Numberish;
  name?: string;
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  readonly?: boolean;
}
interface AtomicRatingEmits {
  (event: 'update:modelValue', value: number): void;
}
const props = withDefaults(defineProps<AtomicRatingProps>(), {
  max: 5,
  size: 'medium',
  name: undefined,
});
const emit = defineEmits<AtomicRatingEmits>();
實作時我們選用的 HTML 標籤會直接影響我們是否需要自行實作鍵盤操作的功能,像是 Element Plus 與 PrimeVue 都有支援鍵盤操作功能,但使用的方法卻截然不同。
Element Plus 渲染出來的 HTML

PrimeVue 渲染出來的 HTML

Element Plus 在實作上單純使用了 <span> 與 <i> 來實作,因此在底層需要自己實踐鍵盤操作的功能。而 PrimeVue 則應用了 <input type="radio"> 來實作,因此不需要額外實作就可以獲得瀏覽器原生支援的鍵盤操作功能。
我們可以看一下這個範例:
<div>
  <input type="radio" name="rating" value="1" />
  <input type="radio" name="rating" value="2" />
  <input type="radio" name="rating" value="3" />
  <input type="radio" name="rating" value="4" />
  <input type="radio" name="rating" value="5" />
</div>
當 <input type="radio"> 有相同的 name 時,我們可以使用鍵盤的上、下、左、右鍵來選取不同的評分。

因此我們使用 <input type="radio"> 來實作 <AtomicRating> 元件,我們就不需要自己處理鍵盤操作的程式碼了。
const id = Math.random().toString(36).slice(2);
const modelValueLocal = ref(props.modelValue ?? 0);
const modelValueWritable = computed({
  get() {
    return props.modelValue ?? modelValueLocal.value;
  },
  set(value) {
    emit('update:modelValue', value);
    modelValueLocal.value = value;
  },
});
<template>
  <span class="atomic-rating">
    <template
      v-for="value in Number(max)"
      :key="value"
    >
      <label
        class="atomic-rating__item"
        :for="`${id}:${value}`"
      >
        <StarFillSvg class="atomic-rating__image" /> <!-- Rating(Selected) -->
        <StarEmptySvg class="atomic-rating__image" /> <!-- Rating(Unselected) -->
        <span class="atomic-rating__reader">
          {{ value }} Stars
        </span>
      </label>
      <input
        :id="`${id}:${value}`"
        v-model="modelValueWritable"
        class="atomic-rating__input"
        :disabled="disabled"
        :name="name ?? id"
        type="radio"
        :value="value"
      >
    </template>
  </span>
</template>
除了 <input> 外,Reader 區塊主要是為了讓螢幕閱讀器能夠正確讀取評分的數值而存在,我們會使用 sr-only 隱藏它。
@mixin sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
.atomic-rating {
  &__reader,
  &__input {
    @include sr-only;
  }
}
接著我們比對 modelValueWritable 與 value 來決定是否要顯示 <StarFillSvg> 或 <StarEmptySvg>。
const selected = (value: number) => value <= modelValueWritable.value;
<label
  class="atomic-rating__item"
  :for="`${id}:${value}`"
>
  <StarFillSvg
    v-if="selected(value)"
    class="atomic-rating__image"
  />
  <StarEmptySvg
    v-else
    class="atomic-rating__image"
  />
</label>
到這裡,我們已經完成了基本的 <AtomicRating> 元件,並且同時支援了點擊與鍵盤操作的功能。

目前的元件只能在 1 到 5 之間切換,如果我們希望支援 0 分評分,目前還尚未實現。為了實現這個功能,我們可以在開頭加上一個隱藏的 <input type="radio">,這樣如果是透過鍵盤操作的使用者就可以選到 0 分的狀態。
<template>
  <span class="atomic-rating">
    <label
      class="atomic-rating__item atomic-rating__item--hidden"
      :for="`${id}:0`"
    >
      Empty
    </label>
    <input
      :id="`${id}:0`"
      v-model="modelValueWritable"
      class="atomic-rating__input"
      :disabled="disabled"
      :name="name ?? id"
      type="radio"
      :value="0"
    >
    <template
      v-for="value in Number(max)"
      :key="value"
    >
      <!-- 略 -->
    </template>
  </span>
</template>
.atomic-rating {
  &__item--hidden {
    @include sr-only;
  }
}

不過光是鍵盤操作可能並不直覺,直覺上我們可能會預期可以透過點擊相同的分數來取消(歸零)評分。不過點擊到已取的 Radio 並不會取消選取,因此我們需要額外加上 click 事件來實做這個功能。
const onClick = (value) => {
  if (props.disabled || props.readonly) return;
  if (value !== modelValueWritable.value) return;
  modelValueWritable.value = 0;
}
<template
  v-for="value in Number(max)"
  :key="value"
>
  <label
    class="atomic-rating__item"
    :for="`${id}:${value}`"
    @click="onClick(value)"
  >
    <!-- 略 -->
  </label>
  <input
    :id="`${id}:${value}`"
    v-model="modelValueWritable"
    class="atomic-rating__input"
    :disabled="disabled"
    :name="name ?? id"
    type="radio"
    :value="value"
  >
</template>
直接說結論,點擊事件確實觸發了,但最後的結果並不如我們預期的會將 modelValueWritable 設定成 0。

事實上,modelValueWritable 的確曾經被設定為 0,但隨著事件傳播的機制,最後 modelValueWritable 又被設定回原本的值,事件傳遞發生的經過如下。
在我們點擊了已選中的 <input> 對應的 <label> (假設 value 為 2)後:
<label> 觸發 click 事件,並將 modelValueWritable 設定為 0。<label> 往上冒泡到最外層直到 Window。modelValueWritable 改變,更新 input 的 checked 狀態。<input> 被動觸發 click 事件,將 modelValueWritable 設定為 2。<input> 往上冒泡到最外層直到 Window。<input> 被動觸發 change 事件由上述的事件觸發經過,我們證實了 modelValueWritable 確實在過程中一度被設定為 0。這是因為點擊了 <label> 後也會觸發 <input> 上的點擊事件,而 <input type="radio"> 被點擊的預設行為就是將 checked 設定為 true,因此 modelValueWritable 最後又被設定回原本的值。
要解決這個問題,我們可以 將 click 事件綁定到 <input> 上
我們可能會很直覺地將 click 事件綁定到 <label> 上,因為在視覺上 <input> 是被隱藏的。但從事件觸發經過的紀錄,我們還是可以觀察到 <input> 的 click 事件還是會被觸發,因此我們可以將 click 事件綁定到 <input> 上。
<!-- 將 `onClick` 綁定到 <input> 上 -->
<input
  :id="`${id}:${value}`"
  v-model="modelValueWritable"
  class="atomic-rating__input"
  :name="name ?? id"
  type="radio"
  :value="value"
  @click="onClick"
>
這樣一來,當使用者點擊已選中的 <input> 時,modelValueWritable 就會被設定為 0。

<AtomicRating> 元件除了可作為表單控制元件外,我們也可以使用 readonly 來作為一般顯示時使用。在 readonly 模式下,我們可以省略 <input> 以減少 HTML 元素數量。
1 到 5 的 Rating
<template
  v-for="value in Number(max)"
  :key="value"
>
  <component
    :is="!readonly ? 'label' : 'span'"
    class="atomic-rating__item"
    :for="!readonly ? `${id}:${value}` : undefined"
    @mouseenter="onMouseenter(value)"
  >
    <!-- 略 -->
    <span
      v-if="!readonly"
      class="atomic-rating__reader"
    >
      {{ value }} Stars
    </span>
  </component>
  <input
    v-if="!readonly"
    :id="`${id}:${value}`"
    v-model="modelValueWritable"
    class="atomic-rating__input"
    :disabled="disabled"
    :name="name ?? id"
    type="radio"
    :value="value"
    @click="onClick"
  >
</template>
代表 0 的 Radio
<template v-if="!(readonly || disabled)">
  <span class="atomic-rating__group atomic-rating__group--hidden">
    <label
      class="atomic-rating__item"
      :for="`${id}:0`"
    >
      Empty
    </label>
    <input
      :id="`${id}:0`"
      v-model="modelValueWritable"
      class="atomic-rating__input"
      :name="name ?? id"
      type="radio"
      :value="0"
    >
  </span>
</template>
這樣在 readonly 模式下,我們就可只渲染出必要的 HTML 元素,減少瀏覽器的開銷。

在某些情境中,我們可能會希望 Rating 元件能夠支援半星(0.5)的評分。

我們加入 allowHalf 這個 prop 來控制是否允許半星評分。
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| allowHalf | boolean | 是否允許半選評分 | 
interface AtomicRatingProps {
  // 略
  allowHalf?: boolean;
}
const props = withDefaults(defineProps<AtomicRatingProps>(), {
  // 略
  allowHalf: false,
});
要實作這個功能,我們可以在每個 Rating Item 上再疊上半個寬度的 Rating Item 就可以了。

我們先用一個 Group 容器將 Rating Item 包起來,並且設定 position: relative。再來只要將半個寬度的 Rating Item 包在 Group 容器中,並且放在 Rating Item 前就可以了。
<template
  v-for="value in Number(max)"
  :key="value"
>
  <span class="atomic-rating__group">
    <!-- value - 0.5 -->
    <label
      class="atomic-rating__item atomic-rating__item--half"
      :for="`${id}:${value - 0.5}`"
    >
      <!-- 略 -->
    </label>
    <input
      :id="`${id}:${value - 0.5}`"
      v-model="modelValueWritable"
      class="atomic-rating__input"
      type="radio"
      :value="value - 0.5"
    >
    <!-- value -->
    <label
      class="atomic-rating__item"
      :for="`${id}:${value}`"
    >
      <!-- 略 -->
    </label>
    <input
      :id="`${id}:${value}`"
      v-model="modelValueWritable"
      class="atomic-rating__input"
      type="radio"
      :value="value"
    >
  </span>
</template>
.atomic-rating {
  &__group {
    position: relative;
  }
  &__item {
    &--half {
      position: absolute;
      top: 0;
      left: 0;
      overflow: hidden;
      width: 50%;
    }
  }
}

接著同樣的,在唯讀模式下我們可以省略掉一些不必要的半個寬度 Rating Item。以 moduleValue 為 2.5 為例,半個寬度 Rating Item 我們只需要留 2.5 那一個,其他都可以被省略。
<template
  v-for="value in Number(max)"
  :key="value"
>
  <span class="atomic-rating__group">
    <!-- value - 0.5 -->
    <!-- `readonly` 時僅保留與 modelValueWritable 相等的半個寬度的 Rating Item -->
    <template
      v-if="readonly
        ? value - 0.5 === modelValueWritable
        : allowHalf
      "
    >
      <label
        class="atomic-rating__item atomic-rating__item--half"
        :for="`${id}:${value - 0.5}`"
      >
        <!-- 略 -->
      </label>
      <input
        :id="`${id}:${value - 0.5}`"
        v-model="modelValueWritable"
        class="atomic-rating__input"
        type="radio"
        :value="value - 0.5"
      >
    </template>
    
    <!-- 略 -->
  </span>
</template>
省略後只會留下必要的結構。

這樣我們就完成了半選評分的功能。
<AtomicRating> 的無障礙設計我們分成兩個部分,一個是「編輯模式」的設定與「唯讀模式」的設定。
因為在編輯模式的時候我們選用了 <input type="radio"> 實作,我們不僅不需要特別處理鍵盤操作的功能,對於螢幕閱讀器來說也已經非常友善。
唯讀模式下,我們把所有的 <input> 都移除,並且將 <label> 換成了 <span>,對於螢幕閱讀器來說,無法辨識這裡的結構代表什麼,因此我們需要使用 role 屬性與 aria-* 讓螢幕閱讀器可以辨識。
在唯讀模式下,我們使用 role="img" 來告訴螢幕閱讀器這個元素是一個圖片。
<template>
  <span
    class="atomic-rating"
    :role="readonly ? 'img' : undefined"
  >
    <!-- 略 -->
  </span>
</template>
除了使用 role 屬性外,我們也需要使用 aria-label 屬性來提供更多的資訊。
<template>
  <span
    :aria-label="readonly ? `${modelValueWritable} Stars` : undefined"
    class="atomic-rating"
    :role="readonly ? 'img' : undefined"
  >
    <!-- 略 -->
  </span>
</template>
<AtomicRating> 元件為使用者提供了一個靈活且可自訂的評分系統,無論是編輯模式下作為表單控制元件,還是靜態顯示評分,都能滿足不同需求。
在編輯模式的無障礙設計中,我們使用了 <input type="radio"> 元素,藉由瀏覽器的原生功能即可達成完整的無障礙支持;在唯讀模式下,則將整個元件視為一張圖片,並使用 role="img" 和 aria-label,確保螢幕閱讀器能夠正確告知使用者這段 HTML 的含義。
原本以為 <AtomicRating> 元件的實作會非常複雜,但善加利用 HTML 原生標籤的特性後,我們也可以用簡單的方式達成需要的功能。
<AtomicRating> 原始碼:AtomicRating.vue